SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
7.1 KB · 143 lines typescript
Raw Blame History
1// Flashcards : file de révision (dues + nouvelles), création manuelle, génération IA.2import { NextResponse } from "next/server";3import { z } from "zod";4import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts";5import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";6import { all, get, run } from "@/lib/db/index.ts";7import { normalizeCourse } from "@/lib/learning/helpers.ts";8import { getPrompt } from "@/lib/prompts.ts";9import { completeChat } from "@/lib/openrouter/client.ts";10import { resolvePreset, getModel, estimateCost } from "@/lib/openrouter/registry.ts";11import { hybridSearch, buildContext } from "@/lib/rag/search.ts";12import { checkBudget, logUsage } from "@/lib/usage.ts";1314export async function GET(req: Request, ctx: { params: Promise<{ course: string }> }) {15  try {16    const user = await requireUser();17    const course = normalizeCourse((await ctx.params).course);18    requireEnrollment(user.id, course);19    const url = new URL(req.url);20    const conceptSlug = url.searchParams.get("concept");21    const conceptFilter = conceptSlug22      ? "AND f.concept_id = (SELECT id FROM concepts WHERE course_code = ? AND slug = ?)"23      : "";24    const params: unknown[] = [user.id, course];25    if (conceptSlug) params.push(course, conceptSlug);2627    // Cartes dues (état existant) puis nouvelles (jamais vues), plafonnées.28    const due = all(29      `SELECT f.id, f.type, f.front, f.back, f.concept_id, c.name as concept_name, cs.ef, cs.interval_days, cs.reps, cs.due_at, cs.favorite30       FROM card_states cs JOIN flashcards f ON f.id = cs.card_id LEFT JOIN concepts c ON c.id = f.concept_id31       WHERE cs.user_id = ? AND f.course_code = ? ${conceptFilter} AND cs.suspended = 0 AND cs.due_at <= datetime('now')32       ORDER BY cs.due_at LIMIT 40`,33      ...params34    );35    const fresh = all(36      `SELECT f.id, f.type, f.front, f.back, f.concept_id, c.name as concept_name37       FROM flashcards f LEFT JOIN concepts c ON c.id = f.concept_id38       WHERE f.course_code = ? ${conceptSlug ? "AND f.concept_id = (SELECT id FROM concepts WHERE course_code = ? AND slug = ?)" : ""}39         AND (f.owner_user_id IS NULL OR f.owner_user_id = ?)40         AND f.id NOT IN (SELECT card_id FROM card_states WHERE user_id = ?)41       ORDER BY f.id LIMIT 15`,42      ...(conceptSlug ? [course, course, conceptSlug, user.id, user.id] : [course, user.id, user.id])43    );44    const stats = {45      total: get<{ n: number }>(46        "SELECT COUNT(*) as n FROM flashcards WHERE course_code = ? AND (owner_user_id IS NULL OR owner_user_id = ?)", course, user.id47      )?.n ?? 0,48      learned: get<{ n: number }>(49        `SELECT COUNT(*) as n FROM card_states cs JOIN flashcards f ON f.id = cs.card_id WHERE cs.user_id = ? AND f.course_code = ? AND cs.reps >= 2`,50        user.id, course51      )?.n ?? 0,52      suspended: get<{ n: number }>(53        `SELECT COUNT(*) as n FROM card_states cs JOIN flashcards f ON f.id = cs.card_id WHERE cs.user_id = ? AND f.course_code = ? AND cs.suspended = 1`,54        user.id, course55      )?.n ?? 0,56    };57    return NextResponse.json({ due, fresh, stats });58  } catch (e) {59    return apiError(e);60  }61}6263const createSchema = z.object({64  action: z.literal("create"),65  conceptSlug: z.string().max(120).nullable(),66  type: z.enum(["qa", "definition", "formula", "error", "comparison", "calc"]).default("qa"),67  front: z.string().min(3).max(2000),68  back: z.string().min(1).max(4000),69});70const generateSchema = z.object({71  action: z.literal("generate"),72  conceptSlug: z.string().min(1).max(120),73  count: z.number().int().min(2).max(10).default(5),74});7576export async function POST(req: Request, ctx: { params: Promise<{ course: string }> }) {77  try {78    await assertSameOrigin();79    const user = await requireUser();80    const course = normalizeCourse((await ctx.params).course);81    requireEnrollment(user.id, course);82    const body = await parseBody(req, z.discriminatedUnion("action", [createSchema, generateSchema]));8384    if (body.action === "create") {85      const conceptId = body.conceptSlug86        ? get<{ id: number }>("SELECT id FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug)?.id ?? null87        : null;88      const r = run(89        `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, owner_user_id, validated)90         VALUES (?, ?, ?, ?, ?, 'user', ?, 0)`,91        course, conceptId, body.type, body.front, body.back, user.id92      );93      return NextResponse.json({ ok: true, cardId: Number(r.lastInsertRowid) });94    }9596    // Génération IA fondée sur le matériel du cours (RAG) — cartes privées à l'étudiant.97    const budget = checkBudget(user.id);98    if (!budget.ok) return NextResponse.json({ error: budget.reason }, { status: 429 });99    const concept = get<{ id: number; name: string; description: string }>(100      "SELECT id, name, description FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug101    );102    if (!concept) return NextResponse.json({ error: "Concept inconnu." }, { status: 404 });103104    const results = await hybridSearch({ query: concept.name + " " + concept.description, spaces: [`official-${course.toLowerCase()}`], k: 6 });105    const context = buildContext(results, 8000);106    const model = await resolvePreset("economique");107    const t0 = Date.now();108    const { text, promptTokens, completionTokens } = await completeChat({109      model,110      messages: [111        { role: "system", content: [getPrompt("base-system"), course === "IMM1003" ? getPrompt("course-imm1003") : getPrompt("course-imm1033"), getPrompt("flashcard-generation")].join("\n\n---\n\n") },112        { role: "user", content: `Concept ciblé : « ${concept.name} » (slug: ${body.conceptSlug}). Produis ${body.count} cartes.\n\nMatériel du cours :\n${context.text}` },113      ],114      jsonMode: true,115      temperature: 0.5,116    });117    const mi = await getModel(model);118    logUsage({ userId: user.id, model, kind: "flashcard-gen", tokensIn: promptTokens, tokensOut: completionTokens, cost: mi ? estimateCost(mi, promptTokens, completionTokens) : 0, latencyMs: Date.now() - t0 });119120    let cards: { type?: string; front?: string; back?: string }[] = [];121    try {122      const parsed = JSON.parse(text.replace(/^```json?\s*|\s*```$/g, ""));123      cards = Array.isArray(parsed.flashcards) ? parsed.flashcards : [];124    } catch {125      return NextResponse.json({ error: "La génération a produit un format invalide. Réessayez." }, { status: 502 });126    }127    const created: number[] = [];128    for (const c of cards.slice(0, body.count)) {129      if (!c.front || !c.back) continue;130      const r = run(131        `INSERT INTO flashcards (course_code, concept_id, type, front, back, created_by, owner_user_id, validated)132         VALUES (?, ?, ?, ?, ?, 'ai', ?, 0)`,133        course, concept.id, ["qa", "definition", "formula", "error", "comparison", "calc"].includes(c.type ?? "") ? c.type : "qa",134        String(c.front).slice(0, 2000), String(c.back).slice(0, 4000), user.id135      );136      created.push(Number(r.lastInsertRowid));137    }138    return NextResponse.json({ ok: true, created: created.length });139  } catch (e) {140    return apiError(e);141  }142}143